Skip to content

feat: send navbar clicks made before analytics loads through the beacon - #909

Open
braianj wants to merge 19 commits into
masterfrom
feat/navbar-early-click-beacon
Open

braianj wants to merge 19 commits into
masterfrom
feat/navbar-early-click-beacon

Conversation

@braianj

@braianj braianj commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Navbar clicks made before analytics is ready are now sent instead of dropped. Analytics loads once the page is idle, and since @dcl/hooks 2.1.0 it reports ready only after Segment has fetched its settings, so until then the click used to be lost.

Before analytics is ready, sessions that started on an exempt page (isAnalyticsDisabledForSession) and bots (isBotClient) send nothing, the same rules that keep the SDK off. Otherwise, a click that replaces the page (a same-tab link, Sign In) goes through the unload-safe beacon the download CTAs use (postSegmentEvent), with the same track_deferred / track_called_at / track_delivered_at fields as every other deferred click. A click that keeps the page open (a new-tab link, ctrl/cmd/shift/alt click, middle click, Jump In) waits in useDeferredTrack's queue and is sent by analytics.js with its full context once it is ready. Once analytics is ready, every click goes through track as today.

Planned for the next release.

How to test (preview, desktop Chrome, a new incognito window, DevTools Network filtered to api.e.decentraland.org, throttling set to Slow 4G):

  1. Open /events and click Learn within the first second, before the settings request finishes. Expected: a POST to /v1/track (the beacon endpoint) goes out before the settings request finishes, and the blog loads.
  2. Reload with throttling off, wait about 10 seconds, then click Learn. Expected: one Click sent by analytics.js (/v1/t), not a beacon.
  3. Open /privacy in a new incognito window, wait a few seconds, then click Learn. Expected: no Click request (no /v1/track or /v1/t).
  4. needs manual verification: open /events under Slow 4G, hover Create and immediately click Creator documentation (new tab). Expected: no /v1/track beacon. Once settings loads, a Click with track_deferred: true goes out through /v1/t. A component test covers this.

@vercel

vercel Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sites Ready Ready Preview Oct 12, 2026 9:39pm UTC

Request Review

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: approve ✅

This is a small change that follows existing patterns: navbar clicks made before analytics is ready now go through the same unload-safe beacon that useDownloadClick uses.

What I checked

  • No double counting. Each click takes exactly one path. When analytics is ready it goes through track and returns; otherwise it goes through the beacon. A beacon click never enters the analytics-next buffer, so it cannot be sent again once Segment finishes loading.
  • The beacon follows the same rules that keep the SDK off.
    • Bots: @dcl/hooks@2.1.0 does export isBotClient, and its AnalyticsProvider uses the same check to skip loading.
    • Exempt sessions: postSegmentEvent deliberately skips the exempt-path check (bypassExemptPathGate), so the explicit isAnalyticsDisabledForSession() call is required, and it is there.
    • No writeKey: postSegmentEvent already returns early.
    • Both checks run before ensureSegmentAnonymousId(), so bots and exempt sessions never get an ajs_anonymous_id cookie written.
  • The navbar links are plain href anchors (full page loads), so the beacon is the right transport for them.
  • Tests. The afterEach with jest.resetAllMocks() plus the new beforeEach keep each case isolated. The new cases cover a person, an exempt session, a bot, and analytics being ready. The ref pattern and the [track] dependencies match useDownloadClick and useDeferredTrack. No unused imports.

Minor findings (P2, not blocking)

  1. P2: sign_in, jump_in and middle-clicks into a new tab do not leave the page, but they also take the beacon path before analytics is ready. This includes the window where the SDK instance exists and would queue them with full context. Details inline.
  2. P2: The download CTA beacon marks its events with track_deferred: true (plus track_called_at and track_delivered_at), but the navbar beacon does not. Details inline.
  3. P2: No test covers a middle-click before analytics loads, or analytics becoming ready after the first render (a rerender before the click). The second is the case the ref exists for.

CI: lint, audit, title-matches-convention, lint-package-json and Vercel pass; test was still pending when I reviewed.
Conventions: the title feat: … and the branch feat/navbar-early-click-beacon follow ADR-6.
Consumer impact: none. This is an internal component change with no public surface.


Reviewed by Jarvis 🤖 · Requested by braianj via GitHub

// links leave the page, so a click before that would be lost with it; the beacon survives the unload. It
// follows the same rules that keep the SDK off: sessions that started on an exempt page, and bots.
if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return
postSegmentEvent(SegmentEvent.CLICK, properties, ensureSegmentAnonymousId())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Some navbar actions do not leave the page: sign_in (button), jump_in (button), and middle-clicks into a new tab (#905). Before analytics is ready they also take the beacon path, so they lose the full analytics-next context (traits, campaign, the SDK-generated messageId) that they would get if queued with useDeferredTrack. That includes the phase where the SDK instance exists but is still fetching settings. The cost is data quality, not lost data. If you want, keep the beacon for anchor clicks that navigate away and route the button and middle-click actions through useDeferredTrack. Fine to leave as is if the uniform payload matters more.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 6bf7784. Only clicks that replace the page (a same-tab link, Sign In, which redirects) take the beacon now. Clicks that keep the page open (a target="_blank" link, ctrl/cmd/shift click, middle click, Jump In) go to useDeferredTrack, which queues them and sends them through analytics-next with full context once it is ready. The decision comes from isSameTabNavigation (helper with tests). The component spec covers both paths, including the queue draining when readiness flips.

// links leave the page, so a click before that would be lost with it; the beacon survives the unload. It
// follows the same rules that keep the SDK off: sessions that started on an exempt page, and bots.
if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return
postSegmentEvent(SegmentEvent.CLICK, properties, ensureSegmentAnonymousId())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] useDownloadClick tags its beacon clicks with track_deferred: true (plus track_called_at and track_delivered_at), so the warehouse can tell beacon-sent Click events apart from SDK-sent ones. Here they can only be told apart through context.library.name = "dcl-sites-beacon". Consider adding at least track_deferred: true so dashboards can split navbar clicks the same way they split download clicks.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in bf52e07: beacon-sent navbar clicks carry track_deferred: true, like useDownloadClick. Queued ones get it from useDeferredTrack. One caveat for dashboards: stg_landing_click / int_landing_tracks in DCL_NEXT only keep action, section and href from extra_data, so splitting there needs that allowlist widened. Until then, context.library.name = "dcl-sites-beacon" is the split available in the warehouse.

@coveralls

coveralls commented Oct 8, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 38311868329

Coverage increased (+0.009%) to 94.19%

Details

  • Coverage increased (+0.009%) from the base build.
  • Patch coverage: 33 of 33 lines across 4 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 15425
Covered Lines: 15036
Line Coverage: 97.48%
Relevant Branches: 7173
Covered Branches: 6249
Branch Coverage: 87.12%
Branches in Coverage %: Yes
Coverage Strength: 25.97 hits per line

💛 - Coveralls

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Verdict: Approve. I found no P0 or P1 issues.

The change does what the description says. Before analytics is ready, a click that leaves the page now goes through postSegmentEvent. A click that keeps the page open (target="_blank", ctrl/cmd/shift-click, middle-click, Jump In) is queued in useDeferredTrack and sent once Segment is ready. After that, clicks go through track.

What I checked

  • isBotClient(userAgent) is exported by @dcl/hooks 2.1.0, and it is the same check AnalyticsProvider uses to keep the SDK off for bots. Together with isAnalyticsDisabledForSession(), the beacon is skipped in the same cases where the SDK never loads. This is stricter than useDownloadClick, which skips the exempt-path check on purpose for conversion CTAs. That fits navbar clicks, which are not conversions.
  • Navbar links are plain <a href> elements, so a same-tab click really does load a new page. The beacon is the right transport for these clicks, and each click is sent once only: the beacon branch returns before reaching the queue.
  • ensureSegmentAnonymousId() creates and saves the ajs_anonymous_id before Segment boots, so beacon clicks and later SDK events share the same anonymous id.
  • trackNavbar now reads readiness from isInitializedRef, which matches useDownloadClick. Removing isInitialized from the deps is correct.
  • The tests cover beacon, new tab then drain, exempt session, bot, and the ready path, plus the helper's modifier and _blank cases.

Findings

  • [P2] The beacon payload has track_deferred: true but not track_called_at / track_delivered_at. useDownloadClick's beacon branch and the useDeferredTrack queue both send all three (see the inline comment).

CI: lint, audit, and title checks pass. test was still running when I reviewed.


Reviewed by Jarvis 🤖 · Requested by braianj via GitHub

if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return
// Flagged like useDownloadClick's beacon clicks, so beacon-sent and SDK-sent clicks can be told apart.
// eslint-disable-next-line @typescript-eslint/naming-convention
postSegmentEvent(SegmentEvent.CLICK, { ...properties, track_deferred: true }, ensureSegmentAnonymousId())

@decentraland-bot decentraland-bot Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The comment says this is "flagged like useDownloadClick's beacon clicks", but that beacon (src/hooks/useDownloadClick.ts) also sends track_called_at and track_delivered_at, and so does every queued click from useDeferredTrack (including the new-tab path just above). As written, a navbar beacon Click is the only deferred click without those fields. Any query that uses them (for example, to measure the delay between the click and when it was sent) will get nulls for these rows. Suggested change to keep the payloads the same:

Suggested change
postSegmentEvent(SegmentEvent.CLICK, { ...properties, track_deferred: true }, ensureSegmentAnonymousId())
const calledAt = Date.now()
postSegmentEvent(
SegmentEvent.CLICK,
// eslint-disable-next-line @typescript-eslint/naming-convention
{ ...properties, track_called_at: calledAt, track_delivered_at: calledAt, track_deferred: true },
ensureSegmentAnonymousId()
)

If you take this, the postSegmentEvent assertion in LandingNavbar.spec.tsx will need expect.any(Number) for the two timestamps.

Note: this suggestion replaces only line 287 and includes its own eslint-disable-next-line, so also delete the existing one on line 286 when you apply it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 78291c4: the beacon payload now carries track_called_at and track_delivered_at (both set to the click time, since the beacon is sent on the spot) alongside track_deferred: true. The spec asserts the timestamps with expect.any(Number).

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: send pre-ready navbar clicks through the beacon

Verdict: Approve. I found no P0 or P1 issues. The approach is sound and every handler sends each click down exactly one path: track, the useDeferredTrack queue, or the beacon. Nothing is counted twice.

Verified

  • Click classification matches what each handler actually does.
    • Navbar links are plain <a> elements, so a same-tab click does a full page load and the beacon is right.
    • Sign In ends in redirectToAuth, which calls window.location.replace. The page unloads, so the beacon is right.
    • Jump In calls preventDefault and opens the modal or launcher. The page stays, so the queue is right.
    • DesktopTabWithDropdown uses window.open(url, '_self'), which always navigates the same tab, so the default leavesPage = true is right.
    • Middle click goes through onAuxClick with false, so the queue is right.
  • The beacon follows the same rules that keep the SDK off. In @dcl/hooks 2.1.0, AnalyticsProvider stays off only for an empty write key or a bot.
    • An empty write key makes postSegmentEvent return early.
    • Bots are checked with the same isBotClient, which 2.1.0 exports for this purpose.
    • The isAnalyticsDisabledForSession() check puts back the exempt-path rule that postSegmentEvent skips on purpose.
    • Neither the SDK nor the beacon has a consent or Do Not Track check, so the beacon skips nothing the SDK applies.
  • Reading isInitialized through a ref avoids beaconing a click with a stale false after the render where Segment became ready. This is the same pattern as useDownloadClick.
  • Security: href and action come only from static navbar config, and the body is built with JSON.stringify and sent as text/plain with credentials: 'omit'. No new PII or injection surface.

Findings (all non-blocking)

  • [P2] isSameTabNavigation ignores altKey (inline).
  • [P2] The deferred-beacon payload is copied from useDownloadClick and has already drifted from it (inline).
  • [P2] The positional leavesPage boolean is hard to read at the call sites. trackNavbar('jump_in', undefined, false) reads better as { leavesPage: false }.
  • [P2] Test gaps. The specs cover Learn (beacon), a new-tab link (queue), exempt sessions and bots. They don't cover:
    • the Sign In beacon
    • the DesktopTabWithDropdown beacon
    • the Jump In queue
    • the payload in the "analytics is ready" case (for example, that track_deferred is not set)
  • [P2] Follow-up, out of scope here. The navbar is the only postSegmentEvent caller that filters bots. The download beacons (useDownloadClick, downloadTracking, downloadPageExit, downloadFunnelExit, and others) still send for bots. Moving the bot check into postSegmentEvent would apply one rule to every caller.

CI

lint, audit, lint-package-json, title convention and Vercel pass. test was still pending when this review was posted. I couldn't run the specs locally in this environment, so please confirm test is green before merging.


Reviewed by Jarvis 🤖 · Requested by braianj via GitHub

*/
function isSameTabNavigation(event: { currentTarget: EventTarget; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }): boolean {
const target = event.currentTarget instanceof Element ? event.currentTarget.getAttribute('target') : null
return target !== '_blank' && !event.ctrlKey && !event.metaKey && !event.shiftKey

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Alt-click (Option-click on macOS) on a link downloads it in Chrome and keeps the page open, but this helper counts it as leaving the page. Before analytics is ready, that click goes out by beacon instead of waiting in the queue for the SDK. It is still counted once, just without the SDK's full context. Consider adding altKey to the parameter type and the check (&& !event.altKey), plus an it.each case for it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 09c759c: isSameTabNavigation also treats altKey as keeping the page, and the it.each covers it.

postSegmentEvent(
SegmentEvent.CLICK,
// eslint-disable-next-line @typescript-eslint/naming-convention
{ ...properties, track_called_at: calledAt, track_delivered_at: calledAt, track_deferred: true },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This copies the deferred-beacon payload from useDownloadClick.ts (track_called_at / track_delivered_at / track_deferred + ensureSegmentAnonymousId()), and the two have already drifted: here track_delivered_at reuses calledAt, while useDownloadClick takes a fresh Date.now(). A small shared helper (e.g. postDeferredClick(properties) next to postSegmentEvent) would keep the beacon callers identical, which the comment above says is the intent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in b7dc1dc. New postDeferredClick(properties) in src/modules/deferredClickBeacon.ts (with a spec) builds the deferral fields once, and both useDownloadClick and the navbar call it. track_delivered_at now takes a fresh Date.now() for both, matching what useDownloadClick did before. useDownloadClick specs pass unchanged.

@braianj

braianj commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Summary-level P2s:

  • Positional leavesPage (609402e): it is now an options object, trackNavbar("jump_in", undefined, { leavesPage: false }).
  • Test gaps (a379f0d): Sign In goes through the beacon. The Discover section tab goes through the beacon before window.open. Jump In is queued, not beaconed, and drains through analytics once ready. The ready-path payload has no deferral fields.
  • Bot check inside postSegmentEvent: left out on purpose. It would stop bot traffic on the download conversion beacons, which changes download funnel numbers, so the data team should see it before it ships. It will be a separate PR.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This is a well-scoped change. Every navbar call site is put on the right path:

  • Clicks that replace the page go through the beacon. These are same-tab <a> links (logo, Learn, credits, user menu, internal dropdown items), the section tab buttons (window.open(url, '_self')) and Sign In (redirectToAuth → location.replace).
  • Clicks that keep the page open wait in the useDeferredTrack queue. These are target="_blank" items, modifier clicks, middle clicks and Jump In (useHangOutAction calls preventDefault).

Each branch returns early, so a click never goes through the beacon and also gets queued, and nothing is sent twice. The beacon gate (exempt-session check plus isBotClient) matches the rules that keep the SDK off. isBotClient is exported by the installed @dcl/hooks@2.1.0. Extracting postDeferredClick into its own module does not change what useDownloadClick sends.

Security: I found no issues. The beacon path checks the exempt-page gate and the bot check before sending. The href values come from fixed navbar config and carry no user input or wallet. userId is only present after the SDK has identified the user, the same as SDK-sent events.

Verification (local, head a379f0d): jest on LandingNavbar, deferredClickBeacon and useDownloadClick: 111/111 passing. tsc --noEmit: clean. eslint on the touched files: clean. CI checks were still pending when I posted this review.

Findings

All findings are P2. None blocks the merge.

  • [P2] LandingNavbar.helpers.ts:56: the parameter type is written inline in a .helpers.ts file, which CLAUDE.md forbids ("Types / interfaces: <thing>.types.ts. Never inline in .client.ts, .helpers.ts…").
  • [P2] LandingNavbar.tsx:261: the ref comment claims more than the ref does.
  • [P2] deferredClickBeacon.ts:8: the doc comment says callers apply the exempt and bot gates, but useDownloadClick applies neither.
  • [P2] Test gaps in LandingNavbar.spec.tsx. These cases are not covered at the component level:
    • A middle click (onAuxClick → queue) before analytics is ready.
    • A ctrl/cmd click on a same-tab link through the component. Only the helper is tested for this.
    • A beaconed click followed by analytics finishing, asserting that track is not called afterwards. This would lock in the no-double-send behavior.

Reviewed by Jarvis 🤖 · Requested by braianj via GitHub

* Whether a primary click on a navbar link replaces the current page. A link that opens in a new tab
* (`target="_blank"`, or ctrl/cmd/shift held) or downloads it (alt/option held) leaves this page alive, so its click can wait for analytics.
*/
function isSameTabNavigation(event: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] CLAUDE.md does not allow inline types in .helpers.ts files. This hand-written shape could be replaced with Pick<React.MouseEvent, 'currentTarget' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'altKey'>, or a named type in LandingNavbar.types.ts. Either option also keeps the type in sync with the DOM event.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e709356: the event is now Pick<MouseEvent, 'currentTarget' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'altKey'> from React, so the instanceof Element guard is gone too.

const { isInitialized, track } = useAnalytics()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)

// Read through a ref (as useDownloadClick does) so a click sees Segment's current readiness even if it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This comment claims more than the ref does. A ref assigned during render only ever holds the value from the last render, so a click cannot see a readiness change that has not been rendered yet. What the ref does is keep trackNavbar stable, so it no longer depends on isInitialized and doesn't invalidate the memoized handlers. Suggest rewording the comment to say that. useDownloadClick has the same wording, so it could be fixed there too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d881b17: both comments (here and in useDownloadClick) now say the ref keeps the handler's identity when analytics becomes ready.

Comment thread src/modules/deferredClickBeacon.ts Outdated
/**
* Sends a `Click` made before analytics was ready through the unload-safe beacon, for clicks that leave the page.
* Adds the same deferral fields `useDeferredTrack` gives queued clicks, so every deferred click carries them
* whichever transport sent it. Callers apply their own gates (exempt session, bots) before calling.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] useDownloadClick is the other caller, and it applies neither gate. Skipping the exempt-session check there is intentional, because /download is exempt but its CTAs must still be counted. Skipping the bot check, though, means a crawler that clicks a download CTA before Segment is ready still sends a beacon. This was already the case before this PR. Two options:

  • Reword the comment so it does not promise gating that only the navbar does.
  • Move the isBotClient(navigator.userAgent) check into postDeferredClick. Both callers would then share it, and the navbar would only keep its exempt-session check.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded in 916d4d4: the comment now says postDeferredClick applies no gate and names what each caller skips. I left the bot check where it is, because moving it into postDeferredClick would stop counting bot clicks on download CTAs and change the download funnel numbers. That goes in a separate PR, once the data team has seen it.

@braianj

braianj commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

The test gaps from the last review are covered in ed2c8c3: a pre-ready middle click and ctrl/cmd click on a same-tab link are queued and drained once analytics is ready, and a beaconed click is not sent again through track after analytics finishes loading.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Verdict: Approve. I found no P0 or P1 issues.

The routing is correct. Before analytics is ready, each click goes to exactly one transport (beacon or queue), never both. Once analytics is ready, every click goes through track as before. I checked each caller's leavesPage value:

  • Leave the page: plain same-tab links, the desktop section tab (window.open(url, '_self'), which stays in the same tab even with a modifier key held), and both Sign In buttons (useSignInRedirect always calls redirectToAuth).
  • Keep the page: _blank links, ctrl/cmd/shift/alt clicks, middle click, and Jump In (it only opens the download modal or the desktop app).

Moving the isInitialized read into a ref is safe: track keeps the same identity while analytics loads, so trackNavbar doesn't capture a stale value. The beacon gate (isAnalyticsDisabledForSession + isBotClient) uses the same conditions that keep the SDK off. It is needed because postSegmentEvent bypasses the exempt-path gate. The postDeferredClick extraction in useDownloadClick sends the same payload as before, and the download CTAs are still ungated on purpose.

Tests cover the beacon path, the queue for new-tab, modifier and middle clicks (including the flush with no double send), the exempt and bot sessions, Sign In, and all four modifier keys in isSameTabNavigation.

Minor (P2)

  • P2 LandingNavbar.tsx:277: in exempt or bot sessions, clicks that keep the page are still pushed into useDeferredTrack's queue. Analytics never loads there, so the queue is never flushed. Nothing is sent, but the closures stay in memory for the life of the component. See the inline comment.
  • P2 (by design, noting it for the data team): a same-tab click made while analytics.js is loaded but not ready goes out through the beacon, so it carries the beacon's context instead of the SDK's (no campaign/UTM enrichment). Analysis that splits on track_deferred = true should keep this in mind.

CI: lint, lint-package-json, title and Vercel are passing. test and audit were still running when I posted this.


Reviewed by Jarvis 🤖 · Requested by braianj via GitHub

}
// Analytics loads once the page is idle and is ready only after Segment fetched its settings. A click
// that stays on the page (new tab, Jump In) waits in the queue with the SDK's full context.
if (!leavesPage) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] In a session that started on an exempt page, or for a bot, analytics never initializes, so this queue is never flushed. Nothing is sent (correct), but each click that keeps the page leaves a closure in queueRef for the life of the navbar. Checking the gate before both branches makes the rule explicit and avoids that:

if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return
if (!leavesPage) {
  deferredTrack(SegmentEvent.CLICK, properties)
  return
}
postDeferredClick(properties)

This doesn't block the merge.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 898f3c7: the exempt-session and bot check now runs before both deferred paths, so those clicks are neither beaconed nor queued. A test for each case middle-clicks a link, forces readiness, and checks nothing is sent; both fail against the previous code.

@braianj

braianj commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the second P2 (beacon context): agreed, a beaconed click has no context.campaign. It does carry context.page.search, the same query string analytics.js parses into context.campaign, so the UTMs of the page where the click happened are still recoverable in the warehouse. Beacon clicks are identified by context.library.name = dcl-sites-beacon. No code change for this one.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: approve ✅

Verdict: approve. I found no P0 or P1 issues at 898f3c7.

What changed since the last round

898f3c7 moves the isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent) check ahead of both deferred paths. In exempt sessions and for bots, clicks are now neither sent by beacon nor queued, so the queue no longer collects closures that never flush. The fix is correct. Both new tests middle-click, force readiness and check that nothing is sent. Middle-click is the right input to test, because before this commit it was the case that reached the queue.

What I checked

  • Each caller is routed to the right path. I traced every trackNavbar / trackNavbarLink caller:
    • Beacon (the click replaces the page): same-tab links (logo, Learn, dropdown items, credits chip, desktop and mobile user menu, mobile sub-items), section tabs (window.open(url, '_self')) and both Sign In buttons (redirectToAuth).
    • Queue (the page stays open): target="_blank" items, ctrl/cmd/shift/alt clicks, middle click (onAuxClick), and Jump In (it opens a modal or the launcher).
    • Keyboard: pressing Enter fires click with button === 0 and carries the modifier flags, so a keyboard click is classified the same way as a mouse click.
  • No double counting. Each branch returns early. A beacon click never enters the queue or the SDK buffer.
  • The queue survives route changes. LandingNavbar is rendered by the parent <Layout> route, so it stays mounted across nested routes and the component-scoped queue is kept.
  • Security (checked against the bug-bounty rules): no issues found. The beacon payload carries only static navbar config (place, event, action, section, href) plus the standard context. userId matches what the SDK sends. Exempt sessions and bots are skipped before ensureSegmentAnonymousId(), so they never get an anonymous-id cookie.
  • No public API change. postDeferredClick is internal, and useDownloadClick sends the same payload as before.
  • Local checks: jest on LandingNavbar, deferredClickBeacon and useDownloadClick passed 120/120. tsc --noEmit and eslint on the touched files are clean. CI (test, lint, audit) was still running when I posted this.

Minor (P2, not blocking)

  • P2: LandingNavbar.spec.tsx:802. The Jump In drain test does not check how many times the click was sent (see the inline suggestion).
  • P2: If analytics never becomes ready for another reason (an ad blocker blocks the settings fetch, or the write key is empty), queued new-tab clicks are never sent. Same-tab clicks may still arrive by beacon. This is no worse than before the PR, when every such click was dropped. It does skew the split between new-tab and same-tab clicks in those sessions, which is worth knowing when reading the data.
  • P2: Not signed in, a visitor clicks Jump In before analytics is ready and then follows the download modal's link in the same tab, still before analytics is ready. The queued jump_in click is then lost when the page unloads. The window is narrow because analytics loads once the page is idle, so this is acceptable as is.

Reviewed by Jarvis 🤖 · Requested by braianj via GitHub

})

it('should send the queued click through analytics', () => {
expect(track).toHaveBeenCalledWith('Click', expect.objectContaining({ action: 'jump_in', track_deferred: true }))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This test checks that the queued jump_in click is sent, but it would also pass if the click were sent twice. The no-double-send guarantee is the main thing this flow has to keep, and the neighbouring analytics is ready cases already pin it with toHaveBeenCalledTimes(1). Adding the same assertion here, and expect(postSegmentEvent).not.toHaveBeenCalled(), would catch a regression where the drain also fires from another path.

Suggested change
expect(track).toHaveBeenCalledWith('Click', expect.objectContaining({ action: 'jump_in', track_deferred: true }))
expect(track).toHaveBeenCalledTimes(1)
expect(track).toHaveBeenCalledWith('Click', expect.objectContaining({ action: 'jump_in', track_deferred: true }))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6cb5e96: the drain test now asserts one track call and no beacon.

@braianj

braianj commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the other two P2s, no code change:

  • Analytics never ready for another reason (ad blocker, empty write key): agreed, queued new-tab clicks are dropped there, as every pre-ready click was before this PR. When reading the data, the new-tab vs same-tab split is skewed toward same-tab in those sessions.
  • Jump In, then the download modal's same-tab link, both before analytics is ready: the queued jump_in is lost on unload. The window is narrow, since analytics loads once the page is idle, so I'm leaving it as is.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

This looks good. Each click goes through exactly one transport (beacon, queue, or track), so nothing is sent twice. The bot and exempt-session checks run before either deferred path, which fixes the case where a queued click would wait forever.

I checked how each call site is classified:

  • Desktop section tab (window.open(url, '_self')), logo, Learn, Credits, mobile links, user menu: these use the beacon on a plain click and the queue for _blank or modifier clicks. Middle clicks always use the queue. ✅
  • Sign In (redirectToAuth replaces the location): beacon ✅
  • Jump In (prevents the default action, opens the modal or deep link; the page stays): queue ✅
  • useDownloadClick → postDeferredClick is a straight extraction that sends the same payload ✅
  • Anonymous id: the beacon uses ensureSegmentAnonymousId, which stores the id where analytics.js picks it up, so beacon events and later SDK events share one id. Bots and exempt sessions return before an id is created. ✅

The tests are thorough: beacon vs queue routing, exact payloads, ordering against window.open, both gates on both paths, and no double send once analytics is ready.

Findings (none block the merge):

  • P2 LandingNavbar.tsx:281: the queue only lives as long as the component, so a queued click is lost if the page unloads before analytics is ready. Example: ctrl-click a link, then plain-click another. See the inline comment.
  • P2 LandingNavbar.helpers.ts:58: only _blank counts as a new tab.
  • P2 LandingNavbar.spec.tsx:779: window.scrollY is overridden and never restored.
  • P2 nit: several beforeEach(async …) blocks in the spec are async but don't await anything (one awaits only some of its steps). This is harmless.

CI: audit, lint, lint-package-json, title and Vercel pass. test was still pending at review time.


Reviewed by Jarvis 🤖 · Requested by braianj via GitHub

// Analytics loads once the page is idle and is ready only after Segment fetched its settings. A click
// that stays on the page (new tab, Jump In) waits in the queue with the SDK's full context.
if (!leavesPage) {
deferredTrack(SegmentEvent.CLICK, properties)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The useDeferredTrack queue only lives as long as the component, so a queued click is dropped if the page unloads before analytics is ready. For example: ctrl-click Creator docs, then plain-click Learn within the same second; or Jump In, then follow the modal's link to /download. The plain click goes out by beacon, but the queued one is lost. The window is narrow, so this doesn't block the merge. If you want to close it, flush any pending queue entries through postDeferredClick on pagehide.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one as is. The queue belongs to useDeferredTrack, which 13 other components share (places cards, section-viewed, link context, Discover). A pagehide flush in the hook would send all of their queued events by beacon, skipping the bot and exempt-session rules the SDK applies to them today, and would change data outside this PR. A navbar-only flush would mean a second queue. The lost click needs two clicks in the window before settings loads, and before this PR every one of those clicks was dropped.

* (`target="_blank"`, or ctrl/cmd/shift held) or downloads it (alt/option held) leaves this page alive, so its click can wait for analytics.
*/
function isSameTabNavigation(event: Pick<MouseEvent, 'currentTarget' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'altKey'>): boolean {
const target = event.currentTarget.getAttribute('target')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Only _blank counts as opening elsewhere. That matches today's config (links render either _blank or no target). A named target such as target="docs" added later would also keep the page alive but would be sent by beacon. Checking !target || target === '_self' would make the intent explicit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2014d91: only no target, _self, _parent and _top (case-insensitive) count as replacing the page. _blank and any named target keep it, so their clicks are queued. Tests cover both groups.


beforeEach(async () => {
onClickJumpIn = jest.fn()
Object.defineProperty(window, 'scrollY', { configurable: true, value: 100 })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] window.scrollY is redefined here and never restored, so the value carries into later tests. The existing override near line 296 has the same problem. Consider restoring the original descriptor in an afterEach.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d915708: a scrollPageTo helper sets window.scrollY, and the top-level afterEach restores the original descriptor. Both overrides, including the older one, go through it.

@braianj

braianj commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Last review round:

  • _blank-only target check: fixed in 2014d91.
  • window.scrollY not restored: fixed in d915708.
  • async setup blocks with no await: fixed in f6755a0 for the blocks this PR adds.
  • Queue lost on unload: answered inline, no change.

Also in this push: the click link and options types moved to LandingNavbar.types.ts (5be1ed7), and the gate tests and the analytics-ready rerender are shared instead of repeated (05249c7). Full suite, lint and build pass locally.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Approve. This PR routes navbar clicks made before analytics is ready by whether the page survives the click. A click that replaces the page (same-tab link, Sign In, section tabs) goes out by beacon. A click that keeps the page (_blank or a named target, ctrl/cmd/shift/alt click, middle click, Jump In) waits in the useDeferredTrack queue. Exempt sessions and bots send nothing. Once analytics is ready, every click goes through track as before.

What I checked

  • Every tracked navbar element (logo, Learn, Credits chip, dropdown items, user menu items, section tabs, Sign In, Jump In) takes the right path:
    • Same-tab <a> links and section tabs (window.open(url, '_self')) take the beacon.
    • Sign In takes the beacon, since redirectToAuth calls location.replace.
    • Jump In is queued, since it calls preventDefault and the page stays.
    • The notification bell, notification items, the avatar toggle, the hamburger menu and Sign Out are not tracked, so none of them can beacon by mistake.
  • No double send: a beaconed click is never also queued, the queue is cleared before it is replayed, and the tests assert one track call and no beacon.
  • Same rules as the SDK: the exempt-session and bot checks match the two conditions under which AnalyticsProvider stays off. postSegmentEvent still returns early when the write key is empty.
  • No consent bypass: the repo has no consent, cookie-banner or Do-Not-Track code that the beacon could skip.
  • href exposes nothing private: it always comes from fixed values in navbarConfig.ts or constants in LandingNavbar.tsx.
  • useDownloadClick refactor: it keeps the same behaviour. The payload, the timestamps and the anonymous-id handling are unchanged, now in postDeferredClick.
  • Locally: tsc -b, eslint on the changed files and jest (7 suites, 131 tests) all pass. CI was still pending when I reviewed.

Findings (P2 only, none blocking)

  • [P2] src/modules/deferredClickBeacon.ts:24: ensureSegmentAnonymousId() runs before postSegmentEvent checks the write key (see the inline comment).
  • [P2] src/modules/segmentBeacon.ts:147-151 (not in this diff): the NOTE there says every caller of this transport fires an explicit conversion event (download CTA click, funnel exit, download_*). That is no longer true now that navbar clicks use it too. Consider updating the comment so the reason it skips the exempt-path gate still holds, and noting that the navbar applies the gate before it calls.

I did not repeat findings from earlier rounds that were resolved or deliberately left as they are, such as the per-component queue being lost when the page unloads.


Reviewed by Jarvis 🤖 · Requested by braianj via GitHub

// eslint-disable-next-line @typescript-eslint/naming-convention
track_deferred: true
},
ensureSegmentAnonymousId()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] ensureSegmentAnonymousId() is evaluated as an argument, so it runs before postSegmentEvent checks the write key.

When SEGMENT_KEY is not set (local builds, or previews without the key), every pre-ready click still writes the one-year ajs_anonymous_id cookie and localStorage entry, then sends nothing. The SDK path writes nothing in that case.

In production the SDK writes the same id a few seconds later, so the only effect there is that the cookie appears earlier. The download CTA already had this order, but this PR applies it to many more clicks.

Possible fix: have postSegmentEvent resolve the anonymous id itself after its key check, or check the key here before calling ensureSegmentAnonymousId().

This branch was successfully deployed

1 active deployment
Preview — 05249c7b Deployed Oct 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants